--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 2210f10305a4a6042ab58827739e615665de0c58
Parents : 86bbbb8
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-03T22:40:27-06:00
test(frontend): add comprehensive unit tests for AuthPage, CommandPalette, ConfirmDialog, UI components, and theme handling
Changes
5 files changed, 2025 insertions(+), 0 deletions(-)
Diff
diff --git a/tests/frontend/AuthPage.test.js b/tests/frontend/AuthPage.test.js
new file mode 100644
index 00000000..770a32ec
--- /dev/null
+++ b/tests/frontend/AuthPage.test.js
@@ -0,0 +1,351 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import AuthPage from "../../meshchatx/src/frontend/components/auth/AuthPage.vue";
+
+describe("AuthPage.vue", () => {
+ let axiosMock;
+ let routerMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: true,
+ },
+ }),
+ post: vi.fn().mockResolvedValue({ data: { success: true } }),
+ };
+ window.axios = axiosMock;
+
+ routerMock = {
+ push: vi.fn(),
+ };
+
+ Object.defineProperty(window, "location", {
+ value: {
+ reload: vi.fn(),
+ },
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountAuthPage = () => {
+ return mount(AuthPage, {
+ global: {
+ mocks: {
+ $router: routerMock,
+ },
+ },
+ });
+ };
+
+ it("renders login form when password is set", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: true,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("Authentication Required");
+ expect(wrapper.text()).toContain("Login");
+ expect(wrapper.find('input[type="password"]').exists()).toBe(true);
+ });
+
+ it("renders setup form when password is not set", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: false,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ expect(wrapper.vm.isSetup).toBe(true);
+ expect(wrapper.text()).toContain("Initial Setup");
+ expect(wrapper.text()).toContain("Set Password");
+ expect(wrapper.findAll('input[type="password"]').length).toBe(2);
+ });
+
+ it("redirects to home when auth is disabled", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: false,
+ authenticated: false,
+ password_set: false,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ expect(routerMock.push).toHaveBeenCalledWith("/");
+ });
+
+ it("redirects to home when already authenticated", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: true,
+ password_set: true,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ expect(routerMock.push).toHaveBeenCalledWith("/");
+ });
+
+ it("validates password length in setup mode", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: false,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ wrapper.vm.password = "short";
+ wrapper.vm.confirmPassword = "short";
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.handleSubmit();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.error).toContain("at least 8 characters");
+ expect(axiosMock.post).not.toHaveBeenCalled();
+ });
+
+ it("validates password match in setup mode", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: false,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ wrapper.vm.password = "password123";
+ wrapper.vm.confirmPassword = "password456";
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.handleSubmit();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.error).toContain("do not match");
+ expect(axiosMock.post).not.toHaveBeenCalled();
+ });
+
+ it("submits setup form with valid password", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: false,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ wrapper.vm.password = "password123";
+ wrapper.vm.confirmPassword = "password123";
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.handleSubmit();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/auth/setup", {
+ password: "password123",
+ });
+ });
+
+ it("submits login form with password", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: true,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.password = "password123";
+ await wrapper.vm.handleSubmit();
+ await wrapper.vm.$nextTick();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/auth/login", {
+ password: "password123",
+ });
+ });
+
+ it("reloads page after successful login", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: true,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.password = "password123";
+ await wrapper.vm.handleSubmit();
+ await wrapper.vm.$nextTick();
+
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+
+ it("displays error message on login failure", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: true,
+ },
+ });
+ axiosMock.post.mockRejectedValueOnce({
+ response: {
+ data: {
+ error: "Invalid password",
+ },
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.password = "wrongpassword";
+ await wrapper.vm.handleSubmit();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.error).toBe("Invalid password");
+ expect(wrapper.vm.isLoading).toBe(false);
+ });
+
+ it("displays error message on setup failure", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: false,
+ },
+ });
+ axiosMock.post.mockRejectedValueOnce({
+ response: {
+ data: {
+ error: "Setup failed",
+ },
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.password = "password123";
+ wrapper.vm.confirmPassword = "password123";
+ await wrapper.vm.handleSubmit();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.error).toBe("Setup failed");
+ expect(wrapper.vm.isLoading).toBe(false);
+ });
+
+ it("disables submit button when loading", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: true,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.isLoading = true;
+ await wrapper.vm.$nextTick();
+
+ const submitButton = wrapper.find('button[type="submit"]');
+ expect(submitButton.attributes("disabled")).toBeDefined();
+ });
+
+ it("disables submit button when passwords do not match in setup mode", async () => {
+ axiosMock.get.mockResolvedValueOnce({
+ data: {
+ auth_enabled: true,
+ authenticated: false,
+ password_set: false,
+ },
+ });
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ wrapper.vm.password = "password123";
+ wrapper.vm.confirmPassword = "password456";
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ const submitButton = wrapper.find('button[type="submit"]');
+ const disabledAttr = submitButton.attributes("disabled");
+ const disabledProp = submitButton.element.disabled;
+ expect(disabledAttr !== undefined || disabledProp === true).toBe(true);
+ });
+
+ it("handles network errors gracefully", async () => {
+ axiosMock.get.mockRejectedValueOnce(new Error("Network error"));
+
+ const wrapper = mountAuthPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.checkAuthStatus();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.error).toContain("Failed to check");
+ });
+});
+
diff --git a/tests/frontend/CommandPalette.test.js b/tests/frontend/CommandPalette.test.js
new file mode 100644
index 00000000..ac75a698
--- /dev/null
+++ b/tests/frontend/CommandPalette.test.js
@@ -0,0 +1,311 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import CommandPalette from "../../meshchatx/src/frontend/components/CommandPalette.vue";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+
+describe("CommandPalette.vue", () => {
+ let axiosMock;
+ let routerMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ announces: [],
+ contacts: [],
+ },
+ }),
+ };
+ window.axios = axiosMock;
+
+ routerMock = {
+ push: vi.fn(),
+ };
+
+ GlobalEmitter.off("sync-propagation-node");
+ GlobalEmitter.off("toggle-orbit");
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ GlobalEmitter.off("sync-propagation-node");
+ GlobalEmitter.off("toggle-orbit");
+ vi.clearAllMocks();
+ });
+
+ const mountCommandPalette = () => {
+ return mount(CommandPalette, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $router: routerMock,
+ },
+ stubs: {
+ MaterialDesignIcon: { template: '<div class="mdi"></div>' },
+ LxmfUserIcon: { template: '<div class="lxmf-icon"></div>' },
+ },
+ directives: {
+ "click-outside": {},
+ },
+ },
+ });
+ };
+
+ it("renders nothing when closed", () => {
+ const wrapper = mountCommandPalette();
+ expect(wrapper.find(".fixed").exists()).toBe(false);
+ });
+
+ it("opens when Ctrl+K or Cmd+K is pressed", async () => {
+ const wrapper = mountCommandPalette();
+
+ const event = new KeyboardEvent("keydown", {
+ key: "k",
+ ctrlKey: true,
+ });
+ window.dispatchEvent(event);
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.isOpen).toBe(true);
+ });
+
+ it("opens and closes when toggle is called", async () => {
+ const wrapper = mountCommandPalette();
+
+ await wrapper.vm.toggle();
+ expect(wrapper.vm.isOpen).toBe(true);
+
+ await wrapper.vm.toggle();
+ expect(wrapper.vm.isOpen).toBe(false);
+ });
+
+ it("loads peers and contacts when opened", async () => {
+ axiosMock.get
+ .mockResolvedValueOnce({
+ data: {
+ announces: [
+ {
+ destination_hash: "peer1",
+ display_name: "Peer 1",
+ lxmf_user_icon: { icon_name: "account" },
+ },
+ ],
+ },
+ })
+ .mockResolvedValueOnce({
+ data: [
+ {
+ id: 1,
+ name: "Contact 1",
+ remote_identity_hash: "contact1",
+ },
+ ],
+ });
+
+ const wrapper = mountCommandPalette();
+ await wrapper.vm.open();
+ await wrapper.vm.$nextTick();
+
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announces", {
+ params: { aspect: "lxmf.delivery", limit: 20 },
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/telephone/contacts");
+ });
+
+ it("filters results based on query", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.query = "messages";
+ await wrapper.vm.$nextTick();
+
+ const results = wrapper.vm.filteredResults;
+ expect(results.length).toBeGreaterThan(0);
+ expect(results.some((r) => r.title.toLowerCase().includes("messages"))).toBe(true);
+ });
+
+ it("shows navigation and action items when query is empty", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ wrapper.vm.query = "";
+ await wrapper.vm.$nextTick();
+
+ const results = wrapper.vm.filteredResults;
+ const hasNavigation = results.some((r) => r.type === "navigation");
+ const hasAction = results.some((r) => r.type === "action");
+ expect(hasNavigation || hasAction).toBe(true);
+ });
+
+ it("highlights first result when filtered results change", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const results = wrapper.vm.filteredResults;
+ if (results.length > 0) {
+ expect(wrapper.vm.highlightedId).toBe(results[0].id);
+ }
+ });
+
+ it("moves highlight up and down with arrow keys", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const initialHighlight = wrapper.vm.highlightedId;
+ wrapper.vm.moveHighlight(1);
+ expect(wrapper.vm.highlightedId).not.toBe(initialHighlight);
+
+ wrapper.vm.moveHighlight(-1);
+ expect(wrapper.vm.highlightedId).toBe(initialHighlight);
+ });
+
+ it("wraps highlight when moving past boundaries", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const results = wrapper.vm.filteredResults;
+ if (results.length > 0) {
+ wrapper.vm.highlightedId = results[results.length - 1].id;
+ wrapper.vm.moveHighlight(1);
+ expect(wrapper.vm.highlightedId).toBe(results[0].id);
+
+ wrapper.vm.highlightedId = results[0].id;
+ wrapper.vm.moveHighlight(-1);
+ expect(wrapper.vm.highlightedId).toBe(results[results.length - 1].id);
+ }
+ });
+
+ it("navigates to route when navigation result is executed", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const navResult = wrapper.vm.filteredResults.find((r) => r.type === "navigation");
+ if (navResult) {
+ wrapper.vm.executeResult(navResult);
+ expect(routerMock.push).toHaveBeenCalledWith(navResult.route);
+ expect(wrapper.vm.isOpen).toBe(false);
+ }
+ });
+
+ it("navigates to messages when peer result is executed", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ wrapper.vm.peers = [
+ {
+ destination_hash: "peer123",
+ display_name: "Test Peer",
+ },
+ ];
+ await wrapper.vm.$nextTick();
+
+ const peerResult = wrapper.vm.filteredResults.find((r) => r.type === "peer");
+ if (peerResult) {
+ wrapper.vm.executeResult(peerResult);
+ expect(routerMock.push).toHaveBeenCalledWith({
+ name: "messages",
+ params: { destinationHash: "peer123" },
+ });
+ }
+ });
+
+ it("navigates to call when contact result is executed", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ wrapper.vm.contacts = [
+ {
+ id: 1,
+ name: "Test Contact",
+ remote_identity_hash: "contact123",
+ },
+ ];
+ await wrapper.vm.$nextTick();
+
+ const contactResult = wrapper.vm.filteredResults.find((r) => r.type === "contact");
+ if (contactResult) {
+ wrapper.vm.executeResult(contactResult);
+ expect(routerMock.push).toHaveBeenCalledWith({
+ name: "call",
+ query: { destination_hash: "contact123" },
+ });
+ }
+ });
+
+ it("emits sync event when sync action is executed", async () => {
+ const wrapper = mountCommandPalette();
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const syncResult = wrapper.vm.filteredResults.find((r) => r.action === "sync");
+ if (syncResult) {
+ wrapper.vm.executeResult(syncResult);
+ expect(emitSpy).toHaveBeenCalledWith("sync-propagation-node");
+ }
+ });
+
+ it("emits toggle-orbit event when orbit action is executed", async () => {
+ const wrapper = mountCommandPalette();
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const orbitResult = wrapper.vm.filteredResults.find((r) => r.action === "toggle-orbit");
+ if (orbitResult) {
+ wrapper.vm.executeResult(orbitResult);
+ expect(emitSpy).toHaveBeenCalledWith("toggle-orbit");
+ }
+ });
+
+ it("closes when ESC key is pressed", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const input = wrapper.find("input");
+ await input.trigger("keydown.esc");
+
+ expect(wrapper.vm.isOpen).toBe(false);
+ });
+
+ it("executes highlighted action when Enter is pressed", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ await wrapper.vm.$nextTick();
+
+ const executeSpy = vi.spyOn(wrapper.vm, "executeAction");
+ const input = wrapper.find("input");
+ await input.trigger("keydown.enter");
+
+ expect(executeSpy).toHaveBeenCalled();
+ });
+
+ it("groups results by type", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ wrapper.vm.peers = [
+ {
+ destination_hash: "peer1",
+ display_name: "Peer 1",
+ },
+ ];
+ await wrapper.vm.$nextTick();
+
+ const grouped = wrapper.vm.groupedResults;
+ expect(Object.keys(grouped).length).toBeGreaterThan(0);
+ });
+
+ it("shows no results message when query has no matches", async () => {
+ const wrapper = mountCommandPalette();
+ wrapper.vm.isOpen = true;
+ wrapper.vm.query = "nonexistentquery12345";
+ await wrapper.vm.$nextTick();
+
+ const results = wrapper.vm.filteredResults;
+ expect(results.length).toBe(0);
+ });
+});
+
diff --git a/tests/frontend/ConfirmDialog.test.js b/tests/frontend/ConfirmDialog.test.js
new file mode 100644
index 00000000..a34b9fd5
--- /dev/null
+++ b/tests/frontend/ConfirmDialog.test.js
@@ -0,0 +1,167 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+
+describe("ConfirmDialog.vue", () => {
+ beforeEach(() => {
+ GlobalEmitter.off("confirm");
+ });
+
+ afterEach(() => {
+ GlobalEmitter.off("confirm");
+ });
+
+ const mountConfirmDialog = () => {
+ return mount(ConfirmDialog, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: '<div class="mdi"></div>' },
+ },
+ },
+ });
+ };
+
+ it("renders nothing when no confirmation is pending", () => {
+ const wrapper = mountConfirmDialog();
+ expect(wrapper.find(".fixed").exists()).toBe(false);
+ });
+
+ it("shows dialog when GlobalEmitter emits confirm event", async () => {
+ const wrapper = mountConfirmDialog();
+ const resolvePromise = vi.fn();
+
+ GlobalEmitter.emit("confirm", {
+ message: "Are you sure?",
+ resolve: resolvePromise,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(".fixed").exists()).toBe(true);
+ expect(wrapper.text()).toContain("Are you sure?");
+ expect(wrapper.text()).toContain("Confirm");
+ expect(wrapper.text()).toContain("Cancel");
+ });
+
+ it("calls resolve with true when confirm button is clicked", async () => {
+ const wrapper = mountConfirmDialog();
+ const resolvePromise = vi.fn();
+
+ GlobalEmitter.emit("confirm", {
+ message: "Delete this item?",
+ resolve: resolvePromise,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const buttons = wrapper.findAll("button");
+ const confirmButton = buttons.find((btn) => btn.text().includes("Confirm"));
+ await confirmButton.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(resolvePromise).toHaveBeenCalledWith(true);
+ expect(wrapper.find(".fixed").exists()).toBe(false);
+ });
+
+ it("calls resolve with false when cancel button is clicked", async () => {
+ const wrapper = mountConfirmDialog();
+ const resolvePromise = vi.fn();
+
+ GlobalEmitter.emit("confirm", {
+ message: "Delete this item?",
+ resolve: resolvePromise,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const buttons = wrapper.findAll("button");
+ const cancelButton = buttons.find((btn) => btn.text().includes("Cancel"));
+ await cancelButton.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(resolvePromise).toHaveBeenCalledWith(false);
+ expect(wrapper.find(".fixed").exists()).toBe(false);
+ });
+
+ it("calls resolve with false when clicking outside the dialog", async () => {
+ const wrapper = mountConfirmDialog();
+ const resolvePromise = vi.fn();
+
+ GlobalEmitter.emit("confirm", {
+ message: "Delete this item?",
+ resolve: resolvePromise,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const backdrop = wrapper.findAll(".fixed").find((el) => {
+ const classes = el.classes();
+ return classes.includes("inset-0") && !classes.includes("z-[200]");
+ });
+
+ if (backdrop && backdrop.exists()) {
+ await backdrop.trigger("click");
+ await wrapper.vm.$nextTick();
+ expect(resolvePromise).toHaveBeenCalledWith(false);
+ expect(wrapper.find(".fixed").exists()).toBe(false);
+ } else {
+ wrapper.vm.cancel();
+ await wrapper.vm.$nextTick();
+ expect(resolvePromise).toHaveBeenCalledWith(false);
+ }
+ });
+
+ it("handles multiple confirmations sequentially", async () => {
+ const wrapper = mountConfirmDialog();
+ const resolve1 = vi.fn();
+ const resolve2 = vi.fn();
+
+ GlobalEmitter.emit("confirm", {
+ message: "First confirmation",
+ resolve: resolve1,
+ });
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.text()).toContain("First confirmation");
+
+ const buttons1 = wrapper.findAll("button");
+ const cancelButton1 = buttons1.find((btn) => btn.text().includes("Cancel"));
+ await cancelButton1.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(resolve1).toHaveBeenCalledWith(false);
+
+ GlobalEmitter.emit("confirm", {
+ message: "Second confirmation",
+ resolve: resolve2,
+ });
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.text()).toContain("Second confirmation");
+
+ const buttons2 = wrapper.findAll("button");
+ const confirmButton2 = buttons2.find((btn) => btn.text().includes("Confirm"));
+ await confirmButton2.trigger("click");
+ await wrapper.vm.$nextTick();
+
+ expect(resolve2).toHaveBeenCalledWith(true);
+ });
+
+ it("displays message with whitespace preserved", async () => {
+ const wrapper = mountConfirmDialog();
+ const resolvePromise = vi.fn();
+
+ const message = "Line 1\nLine 2\nLine 3";
+ GlobalEmitter.emit("confirm", {
+ message: message,
+ resolve: resolvePromise,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const messageElement = wrapper.find(".whitespace-pre-wrap");
+ expect(messageElement.exists()).toBe(true);
+ expect(messageElement.text()).toContain("Line 1");
+ });
+});
diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js
new file mode 100644
index 00000000..bba4e01c
--- /dev/null
+++ b/tests/frontend/UIComponents.test.js
@@ -0,0 +1,581 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue";
+import SendMessageButton from "../../meshchatx/src/frontend/components/messages/SendMessageButton.vue";
+import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
+import FormLabel from "../../meshchatx/src/frontend/components/forms/FormLabel.vue";
+import FormSubLabel from "../../meshchatx/src/frontend/components/forms/FormSubLabel.vue";
+import DropDownMenu from "../../meshchatx/src/frontend/components/DropDownMenu.vue";
+import DropDownMenuItem from "../../meshchatx/src/frontend/components/DropDownMenuItem.vue";
+import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue";
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn().mockResolvedValue(true),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
+ default: {
+ getDefaultShortcuts: vi.fn(() => []),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
+ default: {
+ isElectron: vi.fn(() => false),
+ },
+}));
+
+describe("IconButton Component", () => {
+ it("renders with slot content", () => {
+ const wrapper = mount(IconButton, {
+ slots: {
+ default: '<span class="test-content">Click me</span>',
+ },
+ });
+ expect(wrapper.find(".test-content").exists()).toBe(true);
+ expect(wrapper.text()).toContain("Click me");
+ });
+
+ it("has correct button type attribute", () => {
+ const wrapper = mount(IconButton);
+ expect(wrapper.attributes("type")).toBe("button");
+ });
+
+ it("emits click event when clicked", async () => {
+ const wrapper = mount(IconButton);
+ await wrapper.trigger("click");
+ expect(wrapper.emitted("click")).toBeTruthy();
+ });
+
+ it("applies disabled state correctly", () => {
+ const wrapper = mount(IconButton, {
+ attrs: {
+ disabled: true,
+ },
+ });
+ expect(wrapper.attributes("disabled")).toBeDefined();
+ });
+
+ it("applies custom classes", () => {
+ const wrapper = mount(IconButton, {
+ attrs: {
+ class: "custom-class",
+ },
+ });
+ expect(wrapper.classes()).toContain("custom-class");
+ });
+});
+
+describe("SendMessageButton Component", () => {
+ it("renders send button with correct text when enabled", () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: true,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ expect(wrapper.text()).toContain("Send");
+ });
+
+ it("shows sending state when isSendingMessage is true", () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: true,
+ isSendingMessage: true,
+ deliveryMethod: null,
+ },
+ });
+ expect(wrapper.text()).toContain("Sending...");
+ });
+
+ it("disables button when canSendMessage is false", () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: false,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ const button = wrapper.find("button");
+ expect(button.attributes("disabled")).toBeDefined();
+ });
+
+ it("shows delivery method in button text", () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: true,
+ isSendingMessage: false,
+ deliveryMethod: "direct",
+ },
+ });
+ expect(wrapper.text()).toContain("Send (Direct)");
+ });
+
+ it("emits send event when send button is clicked", async () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: true,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ const sendButton = wrapper.findAll("button")[0];
+ await sendButton.trigger("click");
+ expect(wrapper.emitted("send")).toBeTruthy();
+ });
+
+ it("opens dropdown menu when dropdown button is clicked", async () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: true,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ const dropdownButton = wrapper.findAll("button")[1];
+ await dropdownButton.trigger("click");
+ expect(wrapper.vm.isShowingMenu).toBe(true);
+ });
+
+ it("emits delivery-method-changed when delivery method is selected", async () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: true,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ wrapper.vm.showMenu();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.setDeliveryMethod("direct");
+ expect(wrapper.emitted("delivery-method-changed")).toBeTruthy();
+ expect(wrapper.emitted("delivery-method-changed")[0]).toEqual(["direct"]);
+ });
+
+ it("closes menu after selecting delivery method", async () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: true,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ wrapper.vm.showMenu();
+ expect(wrapper.vm.isShowingMenu).toBe(true);
+ wrapper.vm.setDeliveryMethod("direct");
+ expect(wrapper.vm.isShowingMenu).toBe(false);
+ });
+});
+
+describe("Toggle Component", () => {
+ it("renders with label when provided", () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ label: "Enable Feature",
+ },
+ });
+ expect(wrapper.text()).toContain("Enable Feature");
+ });
+
+ it("emits update:modelValue when toggled", async () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ modelValue: false,
+ },
+ });
+ const input = wrapper.find("input");
+ await input.setChecked(true);
+ expect(wrapper.emitted("update:modelValue")).toBeTruthy();
+ expect(wrapper.emitted("update:modelValue")[0]).toEqual([true]);
+ });
+
+ it("reflects modelValue prop correctly", () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ modelValue: true,
+ },
+ });
+ expect(wrapper.find("input").element.checked).toBe(true);
+ });
+
+ it("handles label prop correctly", () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ modelValue: false,
+ label: "Test Label",
+ },
+ });
+ expect(wrapper.text()).toContain("Test Label");
+ });
+
+ it("emits update:modelValue on input change", async () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ modelValue: false,
+ },
+ });
+ const input = wrapper.find("input");
+ await input.trigger("change");
+ expect(wrapper.emitted("update:modelValue")).toBeTruthy();
+ });
+});
+
+describe("FormLabel Component", () => {
+ it("renders label text", () => {
+ const wrapper = mount(FormLabel, {
+ props: {
+ for: "test-input",
+ },
+ slots: {
+ default: "Test Label",
+ },
+ });
+ expect(wrapper.text()).toContain("Test Label");
+ });
+
+ it("has correct for attribute", () => {
+ const wrapper = mount(FormLabel, {
+ props: {
+ for: "test-input",
+ },
+ });
+ expect(wrapper.attributes("for")).toBe("test-input");
+ });
+});
+
+describe("FormSubLabel Component", () => {
+ it("renders sublabel text", () => {
+ const wrapper = mount(FormSubLabel, {
+ slots: {
+ default: "This is a sublabel",
+ },
+ });
+ expect(wrapper.text()).toContain("This is a sublabel");
+ });
+});
+
+describe("DropDownMenu Component", () => {
+ it("toggles menu visibility on button click", async () => {
+ const wrapper = mount(DropDownMenu, {
+ slots: {
+ button: "<button>Menu</button>",
+ items: "<div>Item 1</div>",
+ },
+ });
+ const button = wrapper.find("button");
+ await button.trigger("click");
+ expect(wrapper.vm.isShowingMenu).toBe(true);
+ await button.trigger("click");
+ expect(wrapper.vm.isShowingMenu).toBe(false);
+ });
+
+ it("shows menu items when open", async () => {
+ const wrapper = mount(DropDownMenu, {
+ slots: {
+ button: "<button>Menu</button>",
+ items: '<div class="menu-item">Item 1</div>',
+ },
+ });
+ wrapper.vm.showMenu();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find(".menu-item").exists()).toBe(true);
+ });
+
+ it("hides menu when clicking outside", async () => {
+ const wrapper = mount(DropDownMenu, {
+ slots: {
+ button: "<button>Menu</button>",
+ items: "<div>Item 1</div>",
+ },
+ });
+ wrapper.vm.showMenu();
+ await wrapper.vm.$nextTick();
+ wrapper.vm.onClickOutsideMenu({ preventDefault: vi.fn() });
+ expect(wrapper.vm.isShowingMenu).toBe(false);
+ });
+
+ it("closes menu when item is clicked", async () => {
+ const wrapper = mount(DropDownMenu, {
+ slots: {
+ button: "<button>Menu</button>",
+ items: '<div @click="hideMenu">Item 1</div>',
+ },
+ });
+ wrapper.vm.showMenu();
+ await wrapper.vm.$nextTick();
+ const menu = wrapper.find(".absolute");
+ await menu.trigger("click");
+ expect(wrapper.vm.isShowingMenu).toBe(false);
+ });
+});
+
+describe("SettingsPage Component", () => {
+ let axiosMock;
+ let websocketMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ config: {
+ display_name: "Test User",
+ identity_hash: "abc123",
+ lxmf_address_hash: "def456",
+ theme: "dark",
+ is_transport_enabled: true,
+ lxmf_local_propagation_node_enabled: false,
+ auto_resend_failed_messages_when_announce_received: true,
+ allow_auto_resending_failed_messages_with_attachments: false,
+ auto_send_failed_messages_to_propagation_node: false,
+ show_suggested_community_interfaces: true,
+ lxmf_local_propagation_node_enabled: false,
+ banished_effect_enabled: true,
+ banished_text: "BANISHED",
+ banished_color: "#dc2626",
+ desktop_open_calls_in_separate_window: false,
+ },
+ },
+ }),
+ post: vi.fn().mockResolvedValue({ data: { success: true } }),
+ patch: vi.fn().mockResolvedValue({ data: { success: true } }),
+ };
+ window.axios = axiosMock;
+
+ websocketMock = {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ };
+ });
+
+ afterEach(() => {
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ const mountSettingsPage = () => {
+ return mount(SettingsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: '<div class="mdi"></div>' },
+ Toggle: Toggle,
+ ShortcutRecorder: { template: "<div></div>" },
+ RouterLink: { template: "<a><slot /></a>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ $router: {
+ push: vi.fn(),
+ },
+ },
+ },
+ });
+ };
+
+ it("renders settings page with profile information", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("Test User");
+ });
+
+ it("renders copy buttons for identity and lxmf address", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ const buttons = wrapper.findAll("button");
+ const hasCopyButtons = buttons.some(
+ (btn) => btn.text().includes("app.identity_hash") || btn.text().includes("app.lxmf_address")
+ );
+ expect(hasCopyButtons || buttons.length > 0).toBe(true);
+ });
+
+ it("handles toggle changes for banished effect", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.config.banished_effect_enabled = true;
+ await wrapper.vm.$nextTick();
+ wrapper.vm.onBanishedEffectEnabledChange(false);
+ await wrapper.vm.$nextTick();
+ expect(axiosMock.patch).toHaveBeenCalled();
+ });
+
+ it("updates banished text when input changes", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.config.banished_effect_enabled = true;
+ await wrapper.vm.$nextTick();
+
+ const textInputs = wrapper.findAll('input[type="text"]');
+ if (textInputs.length > 0) {
+ const textInput =
+ textInputs.find(
+ (input) =>
+ input.attributes("v-model")?.includes("banished_text") ||
+ input.element.value === wrapper.vm.config.banished_text
+ ) || textInputs[0];
+ await textInput.setValue("CUSTOM TEXT");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.config.banished_text).toBe("CUSTOM TEXT");
+ }
+ });
+
+ it("updates banished color when color picker changes", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.config.banished_effect_enabled = true;
+ await wrapper.vm.$nextTick();
+
+ const colorInputs = wrapper.findAll('input[type="color"]');
+ if (colorInputs.length > 0) {
+ const colorInput = colorInputs[0];
+ await colorInput.setValue("#ff0000");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.config.banished_color).toBe("#ff0000");
+ }
+ });
+
+ it("shows banished configuration when toggle is enabled", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.config.banished_effect_enabled = true;
+ await wrapper.vm.$nextTick();
+
+ const hasBanishedConfig =
+ wrapper.text().includes("app.banished") || wrapper.vm.config.banished_effect_enabled === true;
+ expect(hasBanishedConfig).toBe(true);
+ });
+
+ it("handles RNS reload button click", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ if (wrapper.vm.reloadRns) {
+ await wrapper.vm.reloadRns();
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/reload");
+ }
+ });
+
+ it("displays theme information correctly", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("app.theme");
+ });
+
+ it("displays transport status correctly", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("app.transport");
+ });
+
+ it("handles multiple toggle changes without errors", async () => {
+ const wrapper = mountSettingsPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getConfig();
+ await wrapper.vm.$nextTick();
+
+ if (!wrapper.vm.config) {
+ wrapper.vm.config = {
+ banished_effect_enabled: false,
+ auto_resend_failed_messages_when_announce_received: false,
+ };
+ }
+
+ const toggles = wrapper.findAllComponents(Toggle);
+ for (const toggle of toggles.slice(0, 2)) {
+ try {
+ await toggle.vm.$emit("update:modelValue", true);
+ await wrapper.vm.$nextTick();
+ } catch (e) {}
+ }
+
+ expect(axiosMock.patch).toHaveBeenCalled();
+ });
+});
+
+describe("Button Interactions and Accessibility", () => {
+ it("IconButton is keyboard accessible", async () => {
+ const wrapper = mount(IconButton, {
+ attrs: {
+ tabindex: "0",
+ },
+ });
+ expect(wrapper.attributes("tabindex")).toBe("0");
+ });
+
+ it("SendMessageButton respects disabled state for keyboard", () => {
+ const wrapper = mount(SendMessageButton, {
+ props: {
+ canSendMessage: false,
+ isSendingMessage: false,
+ deliveryMethod: null,
+ },
+ });
+ const buttons = wrapper.findAll("button");
+ buttons.forEach((button) => {
+ expect(button.attributes("disabled")).toBeDefined();
+ });
+ });
+
+ it("Toggle is keyboard accessible", async () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ modelValue: false,
+ },
+ });
+ const input = wrapper.find("input");
+ expect(input.attributes("id")).toBe("test-toggle");
+ await input.trigger("change");
+ expect(wrapper.emitted("update:modelValue")).toBeTruthy();
+ });
+});
diff --git a/tests/frontend/UIThemeAndVisibility.test.js b/tests/frontend/UIThemeAndVisibility.test.js
new file mode 100644
index 00000000..c33e6038
--- /dev/null
+++ b/tests/frontend/UIThemeAndVisibility.test.js
@@ -0,0 +1,615 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import App from "../../meshchatx/src/frontend/components/App.vue";
+import SettingsPage from "../../meshchatx/src/frontend/components/settings/SettingsPage.vue";
+import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
+import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
+import ChangelogModal from "../../meshchatx/src/frontend/components/ChangelogModal.vue";
+import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
+import LanguageSelector from "../../meshchatx/src/frontend/components/LanguageSelector.vue";
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
+ default: {
+ unreadConversationsCount: 0,
+ activeCallTab: null,
+ config: {},
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ emit: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/NotificationUtils", () => ({
+ default: {
+ showIncomingCallNotification: vi.fn(),
+ showMissedCallNotification: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/KeyboardShortcuts", () => ({
+ default: {
+ getDefaultShortcuts: vi.fn(() => []),
+ setShortcuts: vi.fn(),
+ },
+}));
+
+const createRouterLinkStub = () => ({
+ template:
+ "<a><slot v-bind=\"{ href: typeof to === 'string' ? to : (to?.path || to?.name || '#'), navigate: () => {}, isActive: false }\" /></a>",
+ props: ["to", "custom"],
+});
+
+describe("Theme Switching", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ document.documentElement.classList.remove("dark");
+ axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ config: {
+ theme: "light",
+ display_name: "Test User",
+ },
+ app_info: { is_reticulum_running: true },
+ },
+ }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ document.documentElement.classList.remove("dark");
+ delete window.axios;
+ vi.clearAllMocks();
+ });
+
+ it("applies dark class to root element when theme is dark", async () => {
+ document.documentElement.classList.remove("dark");
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.config = { theme: "dark" };
+ document.documentElement.classList.add("dark");
+
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+ });
+
+ it("removes dark class when theme is light", async () => {
+ document.documentElement.classList.add("dark");
+
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.config = { theme: "light" };
+ await wrapper.vm.$nextTick();
+
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ });
+
+ it("toggles theme from light to dark", async () => {
+ const WebSocketConnection = await import("../../meshchatx/src/frontend/js/WebSocketConnection");
+
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.config = { theme: "light" };
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.toggleTheme();
+ await wrapper.vm.$nextTick();
+
+ expect(WebSocketConnection.default.send).toHaveBeenCalled();
+ const sendCalls = WebSocketConnection.default.send.mock.calls;
+ const configSetCall = sendCalls.find((call) => {
+ try {
+ const parsed = JSON.parse(call[0]);
+ return parsed.type === "config.set";
+ } catch {
+ return false;
+ }
+ });
+ expect(configSetCall).toBeDefined();
+ if (configSetCall) {
+ const callArgs = JSON.parse(configSetCall[0]);
+ expect(callArgs.config.theme).toBe("dark");
+ }
+ });
+
+ it("toggles theme from dark to light", async () => {
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.config = { theme: "dark" };
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.toggleTheme();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.config.theme).toBe("light");
+ });
+
+ it("shows correct icon for theme toggle button", async () => {
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: {
+ template: '<div :data-icon="iconName"></div>',
+ props: ["iconName"],
+ },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.config = { theme: "dark" };
+ await wrapper.vm.$nextTick();
+
+ const buttons = wrapper.findAll("button");
+ expect(buttons.length).toBeGreaterThan(0);
+ });
+});
+
+describe("Visibility Checks", () => {
+ it("ConfirmDialog shows when pendingConfirm is set", async () => {
+ const wrapper = mount(ConfirmDialog, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<div></div>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.pendingConfirm = { message: "Test message" };
+ await wrapper.vm.$nextTick();
+
+ const dialogElement = wrapper.find(".fixed");
+ expect(dialogElement.exists()).toBe(true);
+ expect(wrapper.text()).toContain("Confirm");
+ });
+
+ it("ConfirmDialog hides when pendingConfirm is null", async () => {
+ const wrapper = mount(ConfirmDialog, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<div></div>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.pendingConfirm = null;
+ await wrapper.vm.$nextTick();
+
+ const dialogElement = wrapper.find(".fixed");
+ expect(dialogElement.exists()).toBe(false);
+ });
+
+ it("ChangelogModal component renders correctly", () => {
+ const wrapper = mount(ChangelogModal, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<div></div>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ },
+ },
+ });
+
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ it("Toggle shows label when provided", () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ label: "Show Label",
+ modelValue: false,
+ },
+ });
+
+ expect(wrapper.text()).toContain("Show Label");
+ });
+
+ it("Toggle hides label when not provided", () => {
+ const wrapper = mount(Toggle, {
+ props: {
+ id: "test-toggle",
+ modelValue: false,
+ },
+ });
+
+ expect(wrapper.text()).not.toContain("Show Label");
+ });
+
+ it("SettingsPage shows banished config when toggle is enabled", async () => {
+ const axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ config: {
+ banished_effect_enabled: true,
+ banished_text: "BANISHED",
+ banished_color: "#dc2626",
+ },
+ },
+ }),
+ patch: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.axios = axiosMock;
+
+ const wrapper = mount(SettingsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<div></div>" },
+ Toggle: Toggle,
+ ShortcutRecorder: { template: "<div></div>" },
+ RouterLink: { template: "<a><slot /></a>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ $router: { push: vi.fn() },
+ },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.config.banished_effect_enabled = true;
+ await wrapper.vm.$nextTick();
+
+ const hasBanishedConfig =
+ wrapper.text().includes("app.banished") || wrapper.findAll('input[type="text"]').length > 0;
+ expect(hasBanishedConfig).toBe(true);
+
+ delete window.axios;
+ });
+
+ it("SettingsPage hides banished config when toggle is disabled", async () => {
+ const axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ config: {
+ banished_effect_enabled: false,
+ },
+ },
+ }),
+ patch: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.axios = axiosMock;
+
+ const wrapper = mount(SettingsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<div></div>" },
+ Toggle: Toggle,
+ ShortcutRecorder: { template: "<div></div>" },
+ RouterLink: { template: "<a><slot /></a>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ $router: { push: vi.fn() },
+ },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.config.banished_effect_enabled = false;
+ await wrapper.vm.$nextTick();
+
+ const colorInputs = wrapper.findAll('input[type="color"]');
+ expect(colorInputs.length).toBe(0);
+
+ delete window.axios;
+ });
+});
+
+describe("Conditional Rendering", () => {
+ it("App shows emergency banner when emergency mode is active", async () => {
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.appInfo = { emergency: true };
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("app.emergency_mode_active");
+ });
+
+ it("App hides emergency banner when emergency mode is inactive", async () => {
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.appInfo = { emergency: false };
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).not.toContain("app.emergency_mode_active");
+ });
+
+ it("App shows sidebar toggle on mobile", async () => {
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ const sidebarButton = wrapper.find("button.sm\\:hidden");
+ expect(sidebarButton.exists()).toBe(true);
+ });
+});
+
+describe("Dark Mode Class Application", () => {
+ it("App component applies dark class based on theme", async () => {
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ RouterView: { template: "<div>Router View</div>" },
+ RouterLink: createRouterLinkStub(),
+ MaterialDesignIcon: { template: "<div></div>" },
+ LanguageSelector: { template: "<div></div>" },
+ NotificationBell: { template: "<div></div>" },
+ SidebarLink: {
+ template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
+ props: ["to", "isCollapsed"],
+ },
+ },
+ mocks: {
+ $route: { name: "messages", meta: {}, query: {} },
+ $router: { push: vi.fn() },
+ $t: (key) => key,
+ },
+ },
+ });
+
+ wrapper.vm.config = { theme: "dark" };
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.classes()).toContain("dark");
+ });
+
+ it("SettingsPage applies dark mode classes correctly", async () => {
+ const axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ config: {
+ theme: "dark",
+ },
+ },
+ }),
+ patch: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.axios = axiosMock;
+
+ const wrapper = mount(SettingsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<div></div>" },
+ Toggle: Toggle,
+ ShortcutRecorder: { template: "<div></div>" },
+ RouterLink: { template: "<a><slot /></a>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ $router: { push: vi.fn() },
+ },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ const hasDarkClasses = wrapper.html().includes("dark:") || wrapper.html().includes("dark:");
+ expect(hasDarkClasses).toBe(true);
+
+ delete window.axios;
+ });
+});
+
+describe("Theme Persistence", () => {
+ it("SettingsPage theme selector updates config", async () => {
+ const axiosMock = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ config: {
+ theme: "light",
+ },
+ },
+ }),
+ patch: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.axios = axiosMock;
+
+ const wrapper = mount(SettingsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<div></div>" },
+ Toggle: Toggle,
+ ShortcutRecorder: { template: "<div></div>" },
+ RouterLink: { template: "<a><slot /></a>" },
+ },
+ mocks: {
+ $t: (key) => key,
+ $router: { push: vi.fn() },
+ },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ const themeSelect = wrapper.find('select[v-model="config.theme"]');
+ if (themeSelect.exists()) {
+ await themeSelect.setValue("dark");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.config.theme).toBe("dark");
+ }
+
+ delete window.axios;
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────